#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>
#include <ESP32-HUB75-MatrixPanel-I2S-DMA.h>
#include <Preferences.h>
#include <Update.h>
#include <StreamString.h>
#include <SPIFFS.h>
#include <driver/i2s.h>

// OTA 状态（跨回调共享）
static volatile size_t  ota_written  = 0;
static volatile size_t  ota_total    = 0;
static volatile bool    ota_active   = false;

// ============ 配置 ============
Preferences preferences;

struct Config {
    char    wifi_ssid[32];
    char    wifi_password[64];
    char    server_ip[16];
    int     server_port;
    bool    ap_mode;
    uint8_t brightness;
    uint8_t rotation;    // 0=0° 1=90° 2=180° 3=270°
    uint8_t color_map;   // 0=RGB 1=RBG 2=GRB 3=GBR 4=BRG 5=BGR
    char    device_id[16]; // 8位可读字符串 + null
} config;
const char*    Ver                  = "20260421";        //版本号
const char*    DEFAULT_SSID        = "Belkin.43EE";
const char*    DEFAULT_PASSWORD    = "19860207";
const char*    DEFAULT_SERVER_IP   = "192.168.100.143";
const int      DEFAULT_SERVER_PORT = 8080;
const uint8_t  DEFAULT_BRIGHTNESS  = 253;
const uint8_t  DEFAULT_ROTATION    = 0;
const uint8_t  DEFAULT_COLOR_MAP   = 0;  // RGB（正常）

// ============ 面板 ============
#define PANEL_RES_X  64
#define PANEL_RES_Y  64
#define PANEL_CHAIN  1
#define TOTAL_WIDTH  (PANEL_RES_X * PANEL_CHAIN)
#define TOTAL_HEIGHT PANEL_RES_Y

#define R1_PIN  23
#define G1_PIN  22
#define B1_PIN  32
#define R2_PIN  33
#define G2_PIN  25
#define B2_PIN  26
#define A_PIN   27
#define B_PIN   14
#define C_PIN   12
#define D_PIN   19
#define E_PIN   18
#define LAT_PIN  16
#define OE_PIN  5
#define CLK_PIN 17

const char* AP_SSID     = "Intrix-Setup";
const char* AP_PASSWORD = "12345678";

// ============ MAX98357A I2S 音频 ============
#define I2S_PORT        I2S_NUM_0
#define I2S_LRC_PIN     13   // Word Select (LRC)
#define I2S_BCLK_PIN    4    // Bit Clock (BCL)
#define I2S_DOUT_PIN    21   // Data (DIN → MAX98357A)
#define I2S_SAMPLE_RATE 16000
#define I2S_CHANNELS    1     // Mono
#define AUDIO_BUF_SIZE  2048  // 单次 DMA 写入字节数

// 音频接收状态机（与帧状态机并列）
enum AudioRxState { AUDIO_IDLE, AUDIO_RECEIVING };
static AudioRxState  audio_rx_state    = AUDIO_IDLE;
static int           audio_rx_expected = 0;
static int           audio_rx_received = 0;
static uint8_t*      audio_rx_buf      = nullptr;
static unsigned long audio_frame_start = 0;
#define AUDIO_RX_TIMEOUT_MS 3000

// ============ 全局对象 ============
MatrixPanel_I2S_DMA* dma_display = nullptr;

// ★ WebServer 完全运行在 core 0（由 AsyncTCP/AsyncWebServer 自己的任务驱动）
// ★ networkTask / displayTask / infoTask 全部运行在 core 1
AsyncWebServer server(80);
AsyncWebSocket ws("/ws");

#define NUM_BUFFERS 2
uint16_t* buffers[NUM_BUFFERS] = {nullptr, nullptr};
volatile int write_buffer = 0;
QueueHandle_t  frame_queue;
TaskHandle_t   display_task_handle;
TaskHandle_t   network_task_handle;
TaskHandle_t   info_display_task_handle;

WiFiClient tcp_client;
volatile bool connected_to_server = false;
volatile bool wifi_connected       = false;

unsigned long last_fps_time          = 0;
int           frame_count            = 0;
float         current_fps            = 0;
unsigned long last_reconnect_attempt = 0;

volatile bool show_info_screen   = true;
volatile bool server_mode_active = false;
volatile bool web_mode_active    = false;

// ============ 启动模式 ============
// config_mode=true  → 纯Web配置模式：只启WebServer，不分配帧缓冲，不连TCP
// config_mode=false → 正常TCP显示模式
// 访问"/"写Flash标志后重启进配置模式；保存配置清除标志后重启回正常模式
bool config_mode = false;

// RGB565 颜色通道重映射
// map: 0=RGB 1=RBG 2=GRB 3=GBR 4=BRG 5=BGR
//
// 正确做法：先把565各通道扩展到8bit，通道交换后再压回565
// 这样避免5bit↔6bit互换时的位深截断错误（高亮度时颜色会飘）
inline uint16_t remapColor(uint16_t px, uint8_t map) {
    if (map == 0) return px;

    // 565 → 8bit（标准扩展：低位补高位，充满整个范围）
    uint8_t r = ((px >> 11) & 0x1F) << 3 | ((px >> 13) & 0x07);
    uint8_t g = ((px >>  5) & 0x3F) << 2 | ((px >>  9) & 0x03);
    uint8_t b = ( px        & 0x1F) << 3 | ( px        & 0x07);

    // 通道交换（全在8bit空间操作，无位深差异）
    uint8_t nr, ng, nb;
    switch (map) {
        case 1: nr=r;  ng=b;  nb=g;  break; // RBG
        case 2: nr=g;  ng=r;  nb=b;  break; // GRB
        case 3: nr=g;  ng=b;  nb=r;  break; // GBR
        case 4: nr=b;  ng=r;  nb=g;  break; // BRG
        case 5: nr=b;  ng=g;  nb=r;  break; // BGR
        default: return px;
    }

    // 8bit → 565（取高位）
    return ((uint16_t)(nr >> 3) << 11) |
           ((uint16_t)(ng >> 2) <<  5) |
            (uint16_t)(nb >> 3);
}

// ============ 非阻塞 TCP 状态机 ============
enum RxState { RX_IDLE, RX_RECEIVING_IMG };
static RxState       rx_state       = RX_IDLE;
static int           rx_expected    = 0;
static int           rx_received    = 0;
static int           rx_buf_idx     = 0;
static String        rx_line_buf    = "";
static unsigned long rx_frame_start = 0;
#define RX_TIMEOUT_MS 3000

// ============ 配置管理 ============

void generateDeviceId(char* buf, size_t len) {
    const char chars[] = "abcdefghijklmnopqrstuvwxyz0123456789";
    for (size_t i = 0; i < len - 1; i++)
        buf[i] = chars[esp_random() % (sizeof(chars) - 1)];
    buf[len - 1] = '\0';
}

void loadConfig() {
    preferences.begin("ledmatrix", false);
    strlcpy(config.wifi_ssid,     preferences.getString("ssid",   DEFAULT_SSID).c_str(),      sizeof(config.wifi_ssid));
    strlcpy(config.wifi_password, preferences.getString("pass",   DEFAULT_PASSWORD).c_str(),  sizeof(config.wifi_password));
    strlcpy(config.server_ip,     preferences.getString("srv_ip", DEFAULT_SERVER_IP).c_str(), sizeof(config.server_ip));
    config.server_port = preferences.getInt  ("srv_port",   DEFAULT_SERVER_PORT);
    config.ap_mode     = preferences.getBool ("ap_mode",    false);
    config.brightness  = preferences.getUChar("brightness", DEFAULT_BRIGHTNESS);
    config.rotation    = preferences.getUChar("rotation",   DEFAULT_ROTATION);
    config.color_map   = preferences.getUChar("color_map",  DEFAULT_COLOR_MAP);
    config_mode        = preferences.getBool ("cfg_mode",   false);  // ★ 配置模式标志

    // 设备ID：若不存在则随机生成并持久化
    String stored_id = preferences.getString("device_id", "");
    if (stored_id.length() < 8) {
        char new_id[16];
        generateDeviceId(new_id, 9); // 8字符+null
        preferences.putString("device_id", new_id);
        strlcpy(config.device_id, new_id, sizeof(config.device_id));
        Serial.printf("新生成设备ID: %s\n", config.device_id);
    } else {
        strlcpy(config.device_id, stored_id.c_str(), sizeof(config.device_id));
    }

    preferences.end();
    Serial.printf("配置: WiFi=%s  服务器=%s:%d  亮度=%d  旋转=%d°  颜色=%d  模式=%s  cfg_mode=%d  ID=%s\n",
        config.wifi_ssid, config.server_ip, config.server_port,
        config.brightness, config.rotation * 90, config.color_map,
        config.ap_mode ? "AP" : "STA", config_mode, config.device_id);
}

void saveConfig() {
    preferences.begin("ledmatrix", false);
    preferences.putString ("ssid",       config.wifi_ssid);
    preferences.putString ("pass",       config.wifi_password);
    preferences.putString ("srv_ip",     config.server_ip);
    preferences.putInt    ("srv_port",   config.server_port);
    preferences.putBool   ("ap_mode",    config.ap_mode);
    preferences.putUChar  ("brightness", config.brightness);
    preferences.putUChar  ("rotation",   config.rotation);
    preferences.putUChar  ("color_map",  config.color_map);
    preferences.putString ("device_id",  config.device_id);
    preferences.end();
}

void resetConfig() {
    preferences.begin("ledmatrix", false); preferences.clear(); preferences.end();
    strlcpy(config.wifi_ssid,     DEFAULT_SSID,      sizeof(config.wifi_ssid));
    strlcpy(config.wifi_password, DEFAULT_PASSWORD,  sizeof(config.wifi_password));
    strlcpy(config.server_ip,     DEFAULT_SERVER_IP, sizeof(config.server_ip));
    config.server_port = DEFAULT_SERVER_PORT;
    config.ap_mode     = false;
    config.brightness  = DEFAULT_BRIGHTNESS;
    config.rotation    = DEFAULT_ROTATION;
    config.color_map   = DEFAULT_COLOR_MAP;
    saveConfig();
}

// ============ 旋转工具 ============

// 把源缓冲区的像素按 rotation 方向写入目标缓冲区
// rotation: 0=正常 1=顺时针90° 2=180° 3=逆时针90°(顺时针270°)
void rotateAndCopy(uint16_t* src, uint16_t* dst, int rot) {
    int W = TOTAL_WIDTH, H = TOTAL_HEIGHT;
    if (rot == 0) {
        memcpy(dst, src, W * H * sizeof(uint16_t));
        return;
    }
    for (int y = 0; y < H; y++) {
        for (int x = 0; x < W; x++) {
            uint16_t px = src[y * W + x];
            int dx, dy;
            switch (rot) {
                case 1: dx = H - 1 - y; dy = x;         break; // 顺时针90°
                case 2: dx = W - 1 - x; dy = H - 1 - y; break; // 180°
                case 3: dx = y;         dy = W - 1 - x;  break; // 逆时针90°
                default: dx = x; dy = y; break;
            }
            dst[dy * W + dx] = px;
        }
    }
}

// ============ LED 显示 ============

void clearDisplay() { dma_display->clearScreen(); }

// ============================================================
// Intrix 开机动画
// 面板：64×64  总时长约 3.5 秒
//
// 分四段：
//   1. 点阵涟漪  —— 圆点从中心向外扩散，模拟 LOGO 背景网格 (~1.0s)
//   2. ∞ 描绘   —— 参数方程逐点勾勒无限符号轨迹             (~0.8s)
//   3. 文字滑入  —— "Intrix" 从底部上滑入场，副标题淡入      (~0.7s)
//   4. 脉冲定格  —— 全场亮度脉冲一次，保持 0.8s 后淡出       (~1.0s)
// ============================================================

// 辅助：在 (cx,cy) 画一个填充圆点，radius=1 → 3×3，radius=2 → 5×5
static void bootDot(int cx, int cy, uint16_t color, int radius = 1) {
    for (int dy = -radius; dy <= radius; dy++)
        for (int dx = -radius; dx <= radius; dx++)
            if (dx*dx + dy*dy <= radius*radius + radius/2)
                if (cx+dx >= 0 && cx+dx < 64 && cy+dy >= 0 && cy+dy < 64)
                    dma_display->drawPixel(cx+dx, cy+dy, color);
}

// 辅助：蓝紫渐变色，t=0→纯蓝，t=255→纯紫
static uint16_t bootGradient(uint8_t t) {
    uint8_t r = (uint8_t)(t * 160 / 255);        // 0 → 160
    uint8_t g = (uint8_t)(20 + t * 10 / 255);    // 20 → 30
    uint8_t b = (uint8_t)(200 + t * 55 / 255);   // 200 → 255
    return dma_display->color565(r, g, b);
}
// 彩色点阵：根据格点位置生成 HSV 彩虹色
// col/row 是格点坐标(0~7)，用来让相邻点颜色不同
static uint16_t bootRainbow(int col, int row) {
    // 色相：左上→右下渐变，0~255 循环
    uint8_t hue = (uint8_t)(col * 20 + row * 15);
    // HSV → RGB（简化版，S=255 V=200）
    uint8_t region = hue / 43;
    uint8_t rem    = (hue % 43) * 6;
    uint8_t p = 0, q = (uint8_t)(200 * (255 - rem) / 255);
    uint8_t t = (uint8_t)(200 * rem / 255);
    uint8_t r, g, b;
    switch (region) {
        case 0: r=200; g=t;   b=p;   break;
        case 1: r=q;   g=200; b=p;   break;
        case 2: r=p;   g=200; b=t;   break;
        case 3: r=p;   g=q;   b=200; break;
        case 4: r=t;   g=p;   b=200; break;
        default:r=200; g=p;   b=q;   break;
    }
    return dma_display->color565(r, g, b);
}

void showBootScreen() {
    const int W = 64, H = 64;
    const int CX = 31, CY = 24;   // 动画中心（偏上，给文字留空间）

    // --------------------------------------------------
    // 阶段1：点阵涟漪
    // 网格：每 7px 一个点，共 8×8=64 个点
    // 按到中心的距离分批亮起
    // --------------------------------------------------
    {
        // 预计算每个格点到中心的距离
        struct GridPt { int8_t x, y; uint8_t dist; uint8_t col, row; };  // ★ 加col/row
        const int COLS = 8, ROWS = 8;
        GridPt pts[64];
        int n = 0;
        float maxD = 0;
        for (int row = 0; row < ROWS; row++) {
            for (int col = 0; col < COLS; col++) {
                int px = 4 + col * 8;
                int py = 2 + row * 8;
                float d = sqrtf((px-CX)*(px-CX) + (py-CY)*(py-CY));
                pts[n++] = { (int8_t)px, (int8_t)py, 0, (uint8_t)col, (uint8_t)row };  // ★
                if (d > maxD) maxD = d;
                pts[n-1].dist = (uint8_t)(d);
            }
        }

        // 50 帧，每帧 20ms → 约 1.0s
        for (int frame = 0; frame < 50; frame++) {
            clearDisplay();
            float threshold = maxD * frame / 49.0f;
            for (int i = 0; i < 64; i++) {
                if (pts[i].dist <= threshold + 0.5f) {
                    uint16_t c = bootRainbow(pts[i].col, pts[i].row);  // ★
                    bootDot(pts[i].x, pts[i].y, c, 2);
                }
            }
            dma_display->flipDMABuffer();
            delay(20);
        }
    }

    // --------------------------------------------------
    // 阶段2：∞ 无限符号描绘
    // Lemniscate of Bernoulli 参数方程：
    //   x = a·cos(t) / (1 + sin²(t))
    //   y = a·sin(t)·cos(t) / (1 + sin²(t))
    // 以 (CX, CY) 为中心，a=18 适合64×64面板
    // --------------------------------------------------
    // --------------------------------------------------
    // 阶段2：∞ 无限符号描绘
    // --------------------------------------------------
    {
        const float PI2 = 6.28318f;
        const float a   = 18.0f;
        const int STEPS = 80;

        clearDisplay();
        for (int row = 0; row < 8; row++) {
            for (int col = 0; col < 8; col++) {
                int px = 4 + col * 8;
                int py = 2 + row * 8;
                bootDot(px, py, bootRainbow(col, row), 2);  // ★ 彩色
            }
        }

        for (int frame = 1; frame <= STEPS; frame++) {
            clearDisplay();
            // 背景点阵（暗化）
            for (int row = 0; row < 8; row++) {
                for (int col = 0; col < 8; col++) {
                    int px = 4 + col * 8;
                    int py = 2 + row * 8;
                    uint16_t c = bootRainbow(col, row);  // ★ 彩色
                    uint8_t r5 = (c >> 11) & 0x1F;
                    uint8_t g6 = (c >>  5) & 0x3F;
                    uint8_t b5 =  c        & 0x1F;
                    c = ((r5/2) << 11) | ((g6/2) << 5) | (b5/2);
                    bootDot(px, py, c, 2);
                }
            }
            // ∞ 轨迹（已描绘部分）
            int drawn = frame * 200 / STEPS;
            for (int s = 0; s < drawn; s++) {
                float t  = PI2 * s / 200.0f;
                float st = sinf(t), ct = cosf(t);
                float denom = 1.0f + st * st;
                float lx = a * ct / denom;
                float ly = a * st * ct / denom;
                int px = CX + (int)(lx + 0.5f);
                int py = CY + (int)(ly * 0.9f + 0.5f);
                uint8_t tc = (uint8_t)(s * 255 / 200);
                uint8_t r  = (uint8_t)(80  + tc * 80  / 255);
                uint8_t g  = (uint8_t)(180 - tc * 120 / 255);
                uint8_t b  = 255;
                if (px >= 0 && px < W && py >= 0 && py < H)
                    dma_display->drawPixel(px, py, dma_display->color565(r, g, b));
            }
            // 轨迹头部亮点
            if (drawn > 0 && drawn <= 200) {
                float t  = PI2 * (drawn-1) / 200.0f;
                float st = sinf(t), ct = cosf(t);
                float denom = 1.0f + st * st;
                float lx = a * ct / denom;
                float ly = a * st * ct / denom;
                int px = CX + (int)(lx + 0.5f);
                int py = CY + (int)(ly * 0.9f + 0.5f);
                bootDot(px, py, dma_display->color565(255, 255, 255), 1);
            }
            dma_display->flipDMABuffer();
            delay(12);
        }
    }

    // --------------------------------------------------
    // 阶段3：文字 "INTRIX" 从底部上滑入场
    // --------------------------------------------------
    {
        const int TEXT_Y_FINAL  = 44;
        const int TEXT_Y_START  = 64;
        const int SLIDE_FRAMES  = 18;

        for (int frame = 0; frame < SLIDE_FRAMES; frame++) {
            float progress = 1.0f - powf(1.0f - (float)frame / (SLIDE_FRAMES - 1), 2.0f);
            int textY = (int)(TEXT_Y_START + (TEXT_Y_FINAL - TEXT_Y_START) * progress);

            clearDisplay();
            // 背景点阵（暗）
            for (int row = 0; row < 8; row++) {
                for (int col = 0; col < 8; col++) {
                    int px = 4 + col * 8;
                    int py = 2 + row * 8;
                    uint16_t c = bootRainbow(col, row);  // ★ 彩色
                    uint8_t r5 = (c >> 11) & 0x1F;
                    uint8_t g6 = (c >>  5) & 0x3F;
                    uint8_t b5 =  c        & 0x1F;
                    c = ((r5/2) << 11) | ((g6/2) << 5) | (b5/2);
                    bootDot(px, py, c, 2);
                }
            }
            // ∞ 完整轨迹
            const float PI2 = 6.28318f;
            const float a   = 18.0f;
            for (int s = 0; s < 200; s++) {
                float t  = PI2 * s / 200.0f;
                float st = sinf(t), ct = cosf(t);
                float denom = 1.0f + st * st;
                float lx = a * ct / denom;
                float ly = a * st * ct / denom;
                int px = CX + (int)(lx + 0.5f);
                int py = CY + (int)(ly * 0.9f + 0.5f);
                uint8_t tc = (uint8_t)(s * 255 / 200);
                uint8_t r  = (uint8_t)(80  + tc * 80  / 255);
                uint8_t g  = (uint8_t)(180 - tc * 120 / 255);
                uint8_t b  = 255;
                if (px >= 0 && px < W && py >= 0 && py < H)
                    dma_display->drawPixel(px, py, dma_display->color565(r, g, b));
            }
            // 文字
            if (textY < 64) {
                dma_display->setTextSize(1);
                dma_display->setTextColor(dma_display->color565(180, 220, 255));
                dma_display->setCursor(14, textY);
                dma_display->print("INTRIX");
            }
            dma_display->flipDMABuffer();
            delay(22);
        }

        // 副标题淡入（8帧）
        for (int frame = 0; frame < 8; frame++) {
            clearDisplay();
            // 背景点阵（暗）
            for (int row = 0; row < 8; row++) {
                for (int col = 0; col < 8; col++) {
                    int px = 4 + col * 8;
                    int py = 2 + row * 8;
                    uint16_t c = bootRainbow(col, row);  // ★ 彩色
                    uint8_t r5 = (c >> 11) & 0x1F;
                    uint8_t g6 = (c >>  5) & 0x3F;
                    uint8_t b5 =  c        & 0x1F;
                    c = ((r5/2) << 11) | ((g6/2) << 5) | (b5/2);
                    bootDot(px, py, c, 2);
                }
            }
            // ∞ 完整轨迹
            const float PI2 = 6.28318f;
            const float a   = 18.0f;
            for (int s = 0; s < 200; s++) {
                float t  = PI2 * s / 200.0f;
                float st = sinf(t), ct = cosf(t);
                float denom = 1.0f + st * st;
                float lx = a * ct / denom;
                float ly = a * st * ct / denom;
                int px = CX + (int)(lx + 0.5f);
                int py = CY + (int)(ly * 0.9f + 0.5f);
                uint8_t tc = (uint8_t)(s * 255 / 200);
                uint8_t r  = (uint8_t)(80  + tc * 80  / 255);
                uint8_t g  = (uint8_t)(180 - tc * 120 / 255);
                uint8_t b  = 255;
                if (px >= 0 && px < W && py >= 0 && py < H)
                    dma_display->drawPixel(px, py, dma_display->color565(r, g, b));
            }
            // INTRIX 主标题
            dma_display->setTextSize(1);
            dma_display->setTextColor(dma_display->color565(180, 220, 255));
            dma_display->setCursor(14, TEXT_Y_FINAL);
            dma_display->print("INTRIX");
            // 副标题逐渐变亮
            uint8_t subAlpha = (uint8_t)(frame * 255 / 7);
            uint8_t subR = subAlpha * 120 / 255;
            uint8_t subG = subAlpha * 160 / 255;
            uint8_t subB = subAlpha;
            dma_display->setTextColor(dma_display->color565(subR, subG, subB));
            dma_display->setCursor(2, TEXT_Y_FINAL + 9);
            dma_display->print("AI POWERED");
            dma_display->flipDMABuffer();
            delay(40);
        }
    }

    // --------------------------------------------------
    // 阶段4：脉冲定格 → 保持 0.8s → 淡出
    // --------------------------------------------------
    {
        auto drawFinal = [&]() {
            clearDisplay();
            // 背景点阵（全亮彩色）
            for (int row = 0; row < 8; row++) {
                for (int col = 0; col < 8; col++) {
                    int px = 4 + col * 8;
                    int py = 2 + row * 8;
                    bootDot(px, py, bootRainbow(col, row), 2);  // ★ 彩色全亮
                }
            }
            // ∞ 完整轨迹
            const float PI2 = 6.28318f;
            const float a   = 18.0f;
            for (int s = 0; s < 200; s++) {
                float t  = PI2 * s / 200.0f;
                float st = sinf(t), ct = cosf(t);
                float denom = 1.0f + st * st;
                float lx = a * ct / denom;
                float ly = a * st * ct / denom;
                int px = CX + (int)(lx + 0.5f);
                int py = CY + (int)(ly * 0.9f + 0.5f);
                uint8_t tc = (uint8_t)(s * 255 / 200);
                uint8_t r  = (uint8_t)(80  + tc * 80  / 255);
                uint8_t g  = (uint8_t)(180 - tc * 120 / 255);
                uint8_t b  = 255;
                if (px >= 0 && px < 64 && py >= 0 && py < 64)
                    dma_display->drawPixel(px, py, dma_display->color565(r, g, b));
            }
            dma_display->setTextSize(1);
            dma_display->setTextColor(dma_display->color565(180, 220, 255));
            dma_display->setCursor(14, 44);
            dma_display->print("INTRIX");
            dma_display->setTextColor(dma_display->color565(200, 200, 200));
            dma_display->setCursor(2, 53);
            dma_display->print("AI POWERED");
            dma_display->flipDMABuffer();
        };

        drawFinal();

        // 亮度脉冲：正常→高亮→正常，共20帧
        uint8_t origBr = config.brightness;
        for (int f = 0; f < 20; f++) {
            float s = sinf(f * 3.14159f / 19.0f);
            uint8_t br = (uint8_t)(origBr + s * (255 - origBr) * 0.6f);
            dma_display->setBrightness8(br);
            delay(25);
        }
        dma_display->setBrightness8(origBr);

        // 保持 0.8s
        delay(800);

        // 淡出：16帧
        for (int f = 0; f < 16; f++) {
            uint8_t br = (uint8_t)(origBr * (15 - f) / 15);
            dma_display->setBrightness8(br);
            delay(18);
        }
        clearDisplay();
        dma_display->flipDMABuffer();
        dma_display->setBrightness8(origBr);
    }
}
// 单行文字像素宽度（字号1时每字符6px）
#define CHAR_W 6

// 滚动显示一行文字，从右侧入场向左滚动直到完全离屏
// 返回时 server_mode_active==true 则中途退出
void scrollOneLine(const char* text, uint16_t color, int y, int speed_ms = 35) {
    int len    = strlen(text);
    int text_w = len * CHAR_W;
    for (int offset = 0; offset <= TOTAL_WIDTH + text_w; offset++) {
        if (server_mode_active) return;
        clearDisplay();
        dma_display->setTextColor(color);
        dma_display->setCursor(TOTAL_WIDTH - offset, y);
        dma_display->print(text);
        dma_display->flipDMABuffer();  // 每帧翻转
        vTaskDelay(pdMS_TO_TICKS(speed_ms));
    }
}

// 滚动 IP 任务：WiFi连上后持续滚动显示IP，直到连上服务器
TaskHandle_t scroll_ip_task_handle = nullptr;

void scrollIPTask(void* p) {
    // 根据实际WiFi模式取IP
    String ip;
    if (WiFi.getMode() == WIFI_AP || WiFi.getMode() == WIFI_AP_STA) {
        ip = WiFi.softAPIP().toString();
    } else {
        ip = WiFi.localIP().toString();
    }

    uint16_t color_label = dma_display->color565(255, 255, 0);    // 黄色标签
    uint16_t color_ip    = dma_display->color565(0,   255, 255);  // 青色IP

    while (!server_mode_active) {
        // 先静态显示标签+IP 1.5秒（如果IP短则够显示）
        clearDisplay();
        dma_display->setTextSize(1);
        dma_display->setTextColor(color_label);
        dma_display->setCursor(2, 8);
        dma_display->print("IP Addr:");
        dma_display->setTextColor(color_ip);
        dma_display->setCursor(2, 22);
        dma_display->print(ip.substring(0, min((int)ip.length(), 10)));  // 最多显示10字符
        if (ip.length() > 10) {
            dma_display->setCursor(2, 32);
            dma_display->print(ip.substring(10));
        }
        // 底部提示
        dma_display->setTextColor(dma_display->color565(180, 180, 180));
        dma_display->setCursor(2, 50);
        dma_display->print("No Server");
        dma_display->flipDMABuffer();  // 静态画面翻转

        vTaskDelay(pdMS_TO_TICKS(1500));
        if (server_mode_active) break;

    }

    scroll_ip_task_handle = nullptr;
    vTaskDelete(NULL);
}

void showWiFiConnectingScreen(int attempt) {
    clearDisplay();
    dma_display->setTextSize(1);
    dma_display->setTextColor(dma_display->color565(255,255,255)); dma_display->setCursor(0,2);  dma_display->print("WiFi Connect");
    dma_display->setTextColor(dma_display->color565(0,255,255));   dma_display->setCursor(0,20);
    char s[11]; strncpy(s, config.wifi_ssid, 10); s[10]='\0'; dma_display->print(s);
    dma_display->setTextColor(dma_display->color565(255,255,0));   dma_display->setCursor(0,36); dma_display->print("Wait");
    for (int i=0; i<(attempt%4); i++) dma_display->print(".");
    dma_display->flipDMABuffer();
}

void showInfoScreen() {
    if (!show_info_screen) return;
    clearDisplay();
    dma_display->setTextSize(1);

    dma_display->setTextColor(config.ap_mode ? dma_display->color565(255,0,255) : dma_display->color565(0,255,0));
    dma_display->setCursor(2,0); dma_display->print(config.ap_mode ? "AP MODE" : "STA MODE");

    dma_display->setTextColor(dma_display->color565(255,255,255));
    dma_display->setCursor(2,10);
    char ssid[13]; strncpy(ssid, config.wifi_ssid, 12); ssid[12]='\0'; dma_display->print(ssid);

    String ip;
    if (WiFi.getMode() == WIFI_AP || WiFi.getMode() == WIFI_AP_STA) {
        ip = WiFi.softAPIP().toString();
    } else {
        ip = WiFi.localIP().toString();
    }
    dma_display->setTextColor(dma_display->color565(0,255,255));
    dma_display->setCursor(2,22);
    int d1 = ip.indexOf('.');
    dma_display->print(ip.substring(0,d1)); dma_display->print(".");
    dma_display->setCursor(2,32);
    String rest = ip.substring(d1+1);
    if (rest.length() > 10) {
        int d2 = rest.indexOf('.');
        dma_display->print(rest.substring(0,d2+1));
        dma_display->setCursor(2,42); dma_display->print(rest.substring(d2+1));
    } else { dma_display->print(rest); }

    dma_display->setTextColor(connected_to_server ? dma_display->color565(0,255,0) : dma_display->color565(255,0,0));
    dma_display->setCursor(2,54);
    dma_display->print(config.ap_mode ? "Setup Mode" : (connected_to_server ? "Server OK" : "No Server"));
    dma_display->flipDMABuffer();
}

// ============ 网页 ============

const char SETUP_PAGE[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<title>Intrix 设置</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
     background:linear-gradient(135deg,#667eea,#764ba2);min-height:100vh;padding:20px}
.container{max-width:500px;margin:0 auto;background:#fff;border-radius:20px;
           box-shadow:0 20px 60px rgba(0,0,0,.3);overflow:hidden}
.header{background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;padding:30px;text-align:center}
.header h1{font-size:24px;margin-bottom:10px}
.header p{opacity:.9;font-size:14px}
.content{padding:30px}
.form-group{margin-bottom:20px}
label{display:block;margin-bottom:8px;color:#333;font-weight:600;font-size:14px}
input[type=text],input[type=password],input[type=number]{width:100%;padding:12px 15px;
  border:2px solid #e0e0e0;border-radius:10px;font-size:16px;transition:all .3s}
input:focus{outline:none;border-color:#667eea}
.slider-wrap{background:#f8f9fa;padding:15px;border-radius:10px;margin-bottom:20px}
.slider{width:100%;height:6px;border-radius:5px;background:#ddd;outline:none;-webkit-appearance:none}
.slider::-webkit-slider-thumb{-webkit-appearance:none;width:22px;height:22px;border-radius:50%;
  background:#667eea;cursor:pointer}
.slider-val{text-align:center;font-size:22px;font-weight:bold;color:#667eea;margin-top:8px}
.mode-sel{display:flex;gap:10px;margin-bottom:20px}
.mode-btn{flex:1;padding:15px;border:2px solid #e0e0e0;border-radius:10px;background:#fff;
          cursor:pointer;text-align:center;transition:all .3s}
.mode-btn.active{border-color:#667eea;background:#f0f4ff;color:#667eea}
.mode-btn h3{font-size:15px;margin-bottom:4px}
.mode-btn p{font-size:12px;color:#666}
.rot-sel{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:20px}
.rot-btn{padding:12px 8px;border:2px solid #e0e0e0;border-radius:10px;background:#fff;
         cursor:pointer;text-align:center;transition:all .3s;font-size:13px}
.rot-btn.active{border-color:#667eea;background:#f0f4ff;color:#667eea;font-weight:600}
.color-sel{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:20px}
.color-btn{padding:10px 4px;border:2px solid #e0e0e0;border-radius:10px;background:#fff;
           cursor:pointer;text-align:center;transition:all .3s;font-size:14px;font-weight:600;letter-spacing:1px}
.color-btn.active{border-color:#667eea;background:#f0f4ff;color:#667eea}
.btn{width:100%;padding:15px;border:none;border-radius:10px;font-size:16px;font-weight:600;
     cursor:pointer;transition:all .3s;margin-bottom:10px}
.btn-primary{background:linear-gradient(135deg,#667eea,#764ba2);color:#fff}
.btn-primary:hover{transform:translateY(-2px);box-shadow:0 5px 20px rgba(102,126,234,.4)}
.btn-secondary{background:#f0f0f0;color:#333}
.btn-success{background:#2ecc71;color:#fff}
.btn-danger{background:#ff4757;color:#fff}
.status{padding:15px;border-radius:10px;margin-bottom:20px;font-size:14px}
.status.success{background:#d4edda;color:#155724;border:1px solid #c3e6cb}
.status.error{background:#f8d7da;color:#721c24;border:1px solid #f5c6cb}
.status.info{background:#d1ecf1;color:#0c5460;border:1px solid #bee5eb}
.hidden{display:none}
.info-box{background:#f8f9fa;border-left:4px solid #667eea;padding:15px;margin-bottom:20px;
          border-radius:0 10px 10px 0}
.info-box h4{color:#667eea;margin-bottom:5px}
.info-box p{color:#666;font-size:13px;line-height:1.7}
h3{margin:20px 0 12px;color:#333}
.ota-box{border:2px dashed #e0e0e0;border-radius:12px;padding:20px;margin-bottom:20px;background:#fafafa}
.ota-drop{border:2px dashed #bbb;border-radius:8px;padding:30px 10px;text-align:center;
          color:#888;font-size:14px;cursor:pointer;transition:all .2s;margin-bottom:12px}
.ota-drop.drag{border-color:#667eea;background:#f0f4ff;color:#667eea}
.ota-drop input{display:none}
.prog-wrap{display:none;margin-bottom:10px}
.prog-bar{width:100%;height:18px;background:#e0e0e0;border-radius:9px;overflow:hidden}
.prog-fill{height:100%;width:0%;background:linear-gradient(90deg,#667eea,#764ba2);
           border-radius:9px;transition:width .3s}
.prog-txt{text-align:center;font-size:13px;color:#555;margin-top:4px}
</style>
</head>
<body>
<div class="container">
  <div class="header">
    <h1>🎨 Intrix 控制面板 Ver.0421</h1>
    <p> LED 显示屏配置</p>
  </div>
  <div class="content">
    <div id="msg" class="status info hidden"></div>

    <div class="info-box">
      <h4>当前状态</h4>
      <p>设备ID：<strong id="devId">-</strong><br>
         WiFi：<strong id="wifiSt">检查中...</strong><br>
         IP：<strong id="ipAddr">-</strong><br>
         服务器：<strong id="srvSt">-</strong><br>
         亮度：<strong id="brVal">-</strong> &nbsp;
         旋转：<strong id="rotVal">-</strong> &nbsp;
         颜色：<strong id="cmVal">-</strong></p>
    </div>

    <h3>🔖 设备 ID</h3>
    <div class="form-group">
      <label>设备标识（服务器用于区分不同客户端）</label>
      <div style="display:flex;gap:8px;align-items:center">
        <input type="text" id="deviceId" maxlength="15" placeholder="8位字母数字" style="flex:1;margin-bottom:0">
        <button class="btn btn-success" onclick="applyDeviceId()" style="width:auto;padding:12px 16px;margin-bottom:0;white-space:nowrap">✅ 应用</button>
        <button class="btn btn-secondary" onclick="randomDeviceId()" style="width:auto;padding:12px 16px;margin-bottom:0;white-space:nowrap">🎲 随机</button>
      </div>
      <small style="color:#888;font-size:12px;display:block;margin-top:6px">修改后立即生效，服务器重连时使用新ID</small>
    </div>

    <h3>💡 亮度</h3>
    <div class="slider-wrap">
      <input type="range" min="0" max="253" value="250" class="slider" id="brSlider">
      <div class="slider-val"><span id="brNum">128</span> / 253</div>
      <button class="btn btn-success" onclick="applyBrightness()" style="margin-top:10px;margin-bottom:0">✨ 应用亮度</button>
    </div>

    <h3>🔄 旋转方向</h3>
    <div class="rot-sel">
      <div class="rot-btn" id="rot0" onclick="selectRot(0)">0° 正常</div>
      <div class="rot-btn" id="rot1" onclick="selectRot(1)">90° 顺时针</div>
      <div class="rot-btn" id="rot2" onclick="selectRot(2)">180° 翻转</div>
      <div class="rot-btn" id="rot3" onclick="selectRot(3)">270° 逆时针</div>
    </div>
    <button class="btn btn-success" onclick="applyRotation()" style="margin-bottom:20px">🔄 应用旋转（即时生效）</button>

    <h3>🎨 RGB 通道映射</h3>
    <div class="color-sel">
      <div class="color-btn" id="cm0" onclick="selectCM(0)"><span style="color:#e74c3c">R</span><span style="color:#2ecc71">G</span><span style="color:#3498db">B</span></div>
      <div class="color-btn" id="cm1" onclick="selectCM(1)"><span style="color:#e74c3c">R</span><span style="color:#3498db">B</span><span style="color:#2ecc71">G</span></div>
      <div class="color-btn" id="cm2" onclick="selectCM(2)"><span style="color:#2ecc71">G</span><span style="color:#e74c3c">R</span><span style="color:#3498db">B</span></div>
      <div class="color-btn" id="cm3" onclick="selectCM(3)"><span style="color:#2ecc71">G</span><span style="color:#3498db">B</span><span style="color:#e74c3c">R</span></div>
      <div class="color-btn" id="cm4" onclick="selectCM(4)"><span style="color:#3498db">B</span><span style="color:#e74c3c">R</span><span style="color:#2ecc71">G</span></div>
      <div class="color-btn" id="cm5" onclick="selectCM(5)"><span style="color:#3498db">B</span><span style="color:#2ecc71">G</span><span style="color:#e74c3c">R</span></div>
    </div>
    <button class="btn btn-success" onclick="applyColorMap()" style="margin-bottom:20px">🎨 应用颜色映射（即时生效）</button>

    <h3>📡 工作模式</h3>
    <div class="mode-sel">
      <div class="mode-btn" id="modeSta" onclick="selMode('sta')"><h3>📡 客户端</h3><p>连接WiFi路由器</p></div>
      <div class="mode-btn" id="modeAp"  onclick="selMode('ap')"><h3>📶 AP热点</h3><p>创建热点配置</p></div>
    </div>

    <div id="wifiSec">
      <h3>WiFi 设置</h3>
      <div class="form-group">
        <label>WiFi 名称 (SSID)</label>
        <input type="text" id="wifiSsid" value="%SSID%">
      </div>
      <div class="form-group">
        <label>WiFi 密码</label>
        <input type="password" id="wifiPass" value="%PASS%">
      </div>
    </div>

    <h3>🖥 服务器设置</h3>
    <div class="form-group">
      <label>服务器 IP</label>
      <input type="text" id="srvIp" value="">
    </div>
    <div class="form-group">
      <label>服务器端口</label>
      <input type="number" id="srvPort" value="8080">
    </div>

    <button class="btn btn-primary" id="saveBtn" onclick="saveConfig()" disabled>⏳ 加载中...</button>
    <button class="btn btn-secondary" onclick="testConn()">🔌 测试服务器连接</button>
    <button class="btn btn-danger"    onclick="doReset()">🔄 恢复出厂设置</button>

    <h3>🔧 固件更新 (OTA)</h3>
    <div class="ota-box">
      <div class="ota-drop" id="otaDrop" onclick="document.getElementById('otaFile').click()"
           ondragover="event.preventDefault();this.classList.add('drag')"
           ondragleave="this.classList.remove('drag')"
           ondrop="onDrop(event)">
        <input type="file" id="otaFile" accept=".bin" onchange="onFileSelect(this.files[0])">
        <div id="otaLabel">📂 点击选择或拖入 .bin 固件文件</div>
      </div>
      <div class="prog-wrap" id="progWrap">
        <div class="prog-bar"><div class="prog-fill" id="progFill"></div></div>
        <div class="prog-txt" id="progTxt">0%</div>
      </div>
      <button class="btn btn-primary" id="otaBtn" onclick="startOTA()" style="margin-bottom:0" disabled>
        ⬆️ 开始烧录
      </button>
    </div>

    <div class="info-box" style="margin-top:20px">
      <h4>💡 提示</h4>
      <p>• 打开此页面时服务器连接会自动暂停以释放内存<br>
         • 关闭页面后自动恢复服务器连接<br>
         • 亮度、旋转、颜色映射可即时生效，无需重启<br>
         • AP热点 SSID：<strong>LED-Matrix-Setup</strong>  密码：<strong>12345678</strong></p>
    </div>
  </div>
</div>

<script>
let curMode = 'sta';
let curRot  = 0;
let curCM   = 0;
const CM_NAMES = ['RGB','RBG','GRB','GBR','BRG','BGR'];

document.getElementById('brSlider').oninput = function(){ document.getElementById('brNum').textContent = this.value; };

// 网页关闭时通知ESP32恢复连接
window.addEventListener('beforeunload', function(){
  navigator.sendBeacon('/api/disconnect');
});

function selMode(m){
  curMode = m;
  document.getElementById('modeSta').classList.toggle('active', m==='sta');
  document.getElementById('modeAp').classList.toggle('active',  m==='ap');
  document.getElementById('wifiSec').classList.toggle('hidden', m==='ap');
}

function selectRot(r){
  curRot = r;
  [0,1,2,3].forEach(i => document.getElementById('rot'+i).classList.toggle('active', i===r));
}

function selectCM(v){
  curCM = v;
  [0,1,2,3,4,5].forEach(i => document.getElementById('cm'+i).classList.toggle('active', i===v));
}

function showMsg(txt, type){
  const el = document.getElementById('msg');
  el.textContent = txt; el.className = 'status '+type;
  el.classList.remove('hidden');
  setTimeout(()=>el.classList.add('hidden'), 5000);
}

async function applyDeviceId(){
  const v = document.getElementById('deviceId').value.trim().replace(/[^a-zA-Z0-9_\-]/g,'');
  if(v.length < 1){ showMsg('ID不能为空', 'error'); return; }
  try{
    const r = await fetch('/api/deviceid', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({device_id:v})});
    const j = await r.json();
    if(j.success){ showMsg('设备ID已设为 '+v, 'success'); document.getElementById('devId').textContent=v; document.getElementById('deviceId').value=v; }
    else showMsg('失败: '+(j.error||''), 'error');
  } catch(e){ showMsg('网络错误','error'); }
}

function randomDeviceId(){
  const chars='abcdefghijklmnopqrstuvwxyz0123456789';
  let id='';
  for(let i=0;i<8;i++) id+=chars[Math.floor(Math.random()*chars.length)];
  document.getElementById('deviceId').value=id;
}

async function applyBrightness(){
  const v = document.getElementById('brSlider').value;
  try {
    const r = await fetch('/api/brightness?value='+v);
    const j = await r.json();
    if(j.success){ showMsg('亮度已设为 '+v, 'success'); document.getElementById('brVal').textContent=v; }
    else showMsg('失败', 'error');
  } catch(e){ showMsg('网络错误', 'error'); }
}

async function applyRotation(){
  try {
    const r = await fetch('/api/rotation?value='+curRot);
    const j = await r.json();
    if(j.success){ showMsg('旋转已设为 '+(curRot*90)+'°', 'success'); document.getElementById('rotVal').textContent=(curRot*90)+'°'; }
    else showMsg('失败', 'error');
  } catch(e){ showMsg('网络错误', 'error'); }
}

async function applyColorMap(){
  try {
    const r = await fetch('/api/colormap?value='+curCM);
    const j = await r.json();
    if(j.success){
      showMsg('颜色映射已设为 '+CM_NAMES[curCM], 'success');
      document.getElementById('cmVal').textContent = CM_NAMES[curCM];
    } else showMsg('失败', 'error');
  } catch(e){ showMsg('网络错误', 'error'); }
}

async function saveConfig(){
  if (!_inited) {
    showMsg('配置尚未加载完成，请稍候再试', 'error');
    return;
  }

  const ssid = document.getElementById('wifiSsid').value.trim();
  if (curMode === 'sta' && ssid === '') {
    showMsg('客户端模式下 WiFi 名称不能为空', 'error');
    return;
  }

  const passInput = document.getElementById('wifiPass').value.trim();   // 新增这一行

  // 构建要发送的数据
  const data = {
    mode: curMode,
    rotation: curRot,
    color_map: curCM,
    ssid: ssid,
    server_ip: document.getElementById('srvIp').value,
    server_port: parseInt(document.getElementById('srvPort').value) || 8080,
    brightness: parseInt(document.getElementById('brSlider').value)
  };

  // ★ 关键改进：只有用户输入了密码，才发送 pass 字段
  if (passInput.length > 0) {
    data.pass = passInput;
  }
  // 如果密码框为空，就不带 pass 这个字段，后端就不会修改密码

  try {
    const r = await fetch('/api/config', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify(data)
    });

    const j = await r.json();
    if (j.success) {
      showMsg('已保存，重启中...', 'success');
      setTimeout(() => location.reload(), 3500);
    } else {
      showMsg('保存失败: ' + (j.error || '未知错误'), 'error');
    }
  } catch(e) {
    showMsg('网络错误', 'error');
  }
}

async function testConn(){
  showMsg('测试中...','info');
  try{ const r=await fetch('/api/test'); const j=await r.json(); showMsg(j.message, j.success?'success':'error'); }
  catch(e){ showMsg('测试失败','error'); }
}

async function doReset(){
  if(!confirm('确定恢复出厂设置？')) return;
  try{ await fetch('/api/reset',{method:'POST'}); showMsg('已重置，重启中...','success'); setTimeout(()=>location.reload(),3500); }
  catch(e){ showMsg('失败','error'); }
}

// ---- OTA ----
let otaFile = null;

function onDrop(e){
  e.preventDefault();
  document.getElementById('otaDrop').classList.remove('drag');
  const f = e.dataTransfer.files[0];
  if(f) setOtaFile(f);
}
function onFileSelect(f){ if(f) setOtaFile(f); }
function setOtaFile(f){
  otaFile = f;
  document.getElementById('otaLabel').textContent = '📄 '+f.name+' ('+Math.round(f.size/1024)+' KB)';
  document.getElementById('otaBtn').disabled = false;
}

async function startOTA(){
  if(!otaFile){ showMsg('请先选择固件文件','error'); return; }
  if(!confirm('确定要烧录新固件？设备将在烧录完成后自动重启。')) return;

  const btn  = document.getElementById('otaBtn');
  const wrap = document.getElementById('progWrap');
  const fill = document.getElementById('progFill');
  const txt  = document.getElementById('progTxt');
  btn.disabled = true;
  wrap.style.display = 'block';
  txt.textContent = '准备上传...';

  // ★ 必须用 FormData 包装，AsyncWebServer 的 upload 回调只处理 multipart/form-data
  //   直接 send(File) 是 raw binary，upload 回调不会触发
  const formData = new FormData();
  formData.append('firmware', otaFile, otaFile.name);

  const xhr = new XMLHttpRequest();
  xhr.open('POST', '/api/update', true);
  // 注意：不要手动设置 Content-Type，让浏览器自动加 boundary

  xhr.upload.onprogress = function(e){
    if(e.lengthComputable){
      const pct = Math.round(e.loaded / e.total * 100);
      fill.style.width = pct + '%';
      txt.textContent  = pct + '%  (' + Math.round(e.loaded/1024) + ' / ' + Math.round(e.total/1024) + ' KB)';
    }
  };

  xhr.onload = function(){
    if(xhr.status === 200){
      try{
        const j = JSON.parse(xhr.responseText);
        if(j.success){
          fill.style.width = '100%';
          txt.textContent  = '✅ 烧录成功，设备重启中...';
          showMsg('固件烧录成功！设备即将重启','success');
          setTimeout(()=>location.reload(), 8000);
        } else {
          txt.textContent = '❌ 烧录失败: ' + j.error;
          showMsg('烧录失败: ' + j.error, 'error');
          btn.disabled = false;
        }
      } catch(e){
        txt.textContent = '❌ 响应解析错误';
        btn.disabled = false;
      }
    } else {
      txt.textContent = '❌ HTTP ' + xhr.status;
      showMsg('上传失败 HTTP ' + xhr.status, 'error');
      btn.disabled = false;
    }
  };

  xhr.onerror = function(){
    // 设备重启时 TCP 连接自然断开，属于正常现象
    txt.textContent = '📡 连接断开（设备正在重启...）';
    showMsg('固件已发送，设备正在重启，请稍候刷新页面','info');
    setTimeout(()=>location.reload(), 8000);
  };

  xhr.send(formData);  // ★ 发 FormData 而不是 otaFile
}

let _inited = false;  // 只在首次加载时填充表单

async function updateStatus(){
  try{
    const r = await fetch('/api/status');
    const d = await r.json();
    document.getElementById('devId').textContent     = d.device_id || '-';
    document.getElementById('wifiSt').textContent  = d.wifi_connected ? '已连接 ('+d.ssid+')' : '未连接';
    document.getElementById('ipAddr').textContent   = d.ip || '-';
    document.getElementById('srvSt').textContent    = d.server_connected ? '已连接（网页打开时暂停）' : '未连接';
    document.getElementById('brVal').textContent    = d.brightness;
    document.getElementById('rotVal').textContent   = (d.rotation*90)+'°';
    document.getElementById('cmVal').textContent    = CM_NAMES[d.color_map] || '-';

    if (!_inited) {
      _inited = true;
      document.getElementById('brSlider').value    = d.brightness;
      document.getElementById('brNum').textContent  = d.brightness;
      document.getElementById('wifiSsid').value    = d.ssid        || '';
      document.getElementById('wifiPass').value    = d.pass        || '';
      document.getElementById('srvIp').value       = d.server_ip   || '';
      document.getElementById('srvPort').value     = d.server_port || 8080;
      document.getElementById('deviceId').value    = d.device_id   || '';
      curMode = d.ap_mode ? 'ap' : 'sta';
      curRot  = d.rotation  || 0;
      curCM   = d.color_map || 0;
      selMode(curMode);
      selectRot(curRot);
      selectCM(curCM);
      // 加载完成后才启用保存按钮
      document.getElementById('saveBtn').disabled = false;
      document.getElementById('saveBtn').textContent = '💾 保存并重启';
    }
  } catch(e){}
}

selMode(curMode);
selectRot(curRot);
selectCM(curCM);
updateStatus();
setInterval(updateStatus, 3000);

// 超时保障：如果3秒内 /api/status 没有响应，仍然解锁保存按钮
// 避免网络慢时按钮永远禁用
setTimeout(function(){
  if (!_inited) {
    _inited = true;
    document.getElementById('saveBtn').disabled = false;
    document.getElementById('saveBtn').textContent = '💾 保存并重启';
  }
}, 3000);
</script>
</body>
</html>
)rawliteral";

// AP模式专用极简页面，只有SSID、密码、服务器IP和端口，体积<2KB
const char AP_PAGE[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>LED Matrix WiFi设置</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:sans-serif;background:#667eea;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px}
.card{background:#fff;border-radius:16px;padding:28px 24px;width:100%;max-width:360px;box-shadow:0 8px 32px rgba(0,0,0,.2)}
h1{font-size:18px;color:#333;margin-bottom:6px}
p{font-size:13px;color:#888;margin-bottom:20px}
label{display:block;font-size:13px;font-weight:600;color:#444;margin-bottom:6px}
input{width:100%;padding:11px 14px;border:2px solid #e0e0e0;border-radius:8px;font-size:15px;margin-bottom:16px;outline:none}
input:focus{border-color:#667eea}
.row{display:flex;gap:10px}
.row input:first-child{flex:1}
.row input:last-child{width:90px;flex:none}
button{width:100%;padding:13px;background:#667eea;color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer}
button:active{opacity:.85}
.msg{margin-top:14px;padding:10px;border-radius:8px;font-size:13px;text-align:center;display:none}
.ok{background:#d4edda;color:#155724}
.err{background:#f8d7da;color:#721c24}
.sep{border:none;border-top:1px solid #eee;margin:4px 0 20px}
</style>
</head>
<body>
<div class="card">
  <h1>🔧 LED Matrix 配网</h1>
  <p>填写WiFi和服务器信息后点保存，设备将重启并自动连接</p>
  <label>WiFi 名称 (SSID)</label>
  <input type="text" id="ssid" placeholder="输入WiFi名称" autocomplete="off">
  <label>WiFi 密码</label>
  <input type="password" id="pass" placeholder="输入WiFi密码">
  <hr class="sep">
  <label>服务器 IP 地址</label>
  <input type="text" id="srvIp" placeholder="例如 192.168.1.100">
  <label>服务器端口</label>
  <input type="number" id="srvPort" placeholder="8080" value="8080">
  <button onclick="save()">💾 保存并重启</button>
  <div class="msg" id="msg"></div>
</div>
<script>
function showMsg(t,ok){var el=document.getElementById('msg');el.textContent=t;el.className='msg '+(ok?'ok':'err');el.style.display='block';}
async function save(){
  var s=document.getElementById('ssid').value.trim();
  var p=document.getElementById('pass').value;
  var ip=document.getElementById('srvIp').value.trim();
  var port=parseInt(document.getElementById('srvPort').value)||8080;
  if(!s){showMsg('请输入WiFi名称',false);return;}
  showMsg('保存中...',true);
  try{
    var r=await fetch('/api/wifi',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({ssid:s,pass:p,server_ip:ip,server_port:port})});
    var j=await r.json();
    if(j.success){showMsg('已保存，设备即将重启连接 '+s,true);}
    else{showMsg('保存失败: '+(j.error||'未知错误'),false);}
  }catch(e){showMsg('网络错误',false);}
}
</script>
</body>
</html>
)rawliteral";

// ============ SPIFFS ============
// 开机把 PROGMEM 里的 HTML 写入 SPIFFS，之后直接流式发送，零 heap

void initSPIFFS() {
    if (!SPIFFS.begin(true)) {
        Serial.println("SPIFFS 挂载失败！");
        return;
    }
    Serial.printf("SPIFFS 挂载成功，总空间: %d  已用: %d\n",
                  SPIFFS.totalBytes(), SPIFFS.usedBytes());

    // 辅助函数：分块从PROGMEM写入SPIFFS，每块512字节，不占大块heap
    auto writeProgmemToSPIFFS = [](const char* progmem_src, const char* path) {
        size_t len = strlen_P(progmem_src);
        File f = SPIFFS.open(path, FILE_WRITE);
        if (!f) { Serial.printf("SPIFFS 写入失败: %s\n", path); return; }
        const size_t CHUNK = 512;
        char buf[CHUNK];
        size_t offset = 0;
        while (offset < len) {
            size_t to_read = min(CHUNK, len - offset);
            memcpy_P(buf, progmem_src + offset, to_read);
            f.write((uint8_t*)buf, to_read);
            offset += to_read;
        }
        f.close();
        Serial.printf("SPIFFS 写入完成: %s  (%d bytes)\n", path, (int)len);
    };

    writeProgmemToSPIFFS(SETUP_PAGE, "/index.html");  // 完整设置页
    writeProgmemToSPIFFS(AP_PAGE,    "/ap.html");      // AP模式极简页
}

// ============ Web 服务器 ============
// HTML 从 SPIFFS 流式发送，AsyncWebServer 内部分块读取，零 heap 占用
// 所有动态值由 JS 启动后从 /api/status 拉取填充
String getSetupPage() {
    String p = String(SETUP_PAGE);
    p.replace("%SSID%",         config.wifi_ssid);
    p.replace("%PASS%",         config.wifi_password);
    p.replace("%SERVER_IP%",    config.server_ip);
    p.replace("%SERVER_PORT%",  String(config.server_port));
    p.replace("%BRIGHTNESS%",   String(config.brightness));
    p.replace("%ROTATION%",     String(config.rotation));
    p.replace("%ROTATION_DEG%", String(config.rotation * 90));
    p.replace("%CURRENT_MODE%", config.ap_mode ? "ap" : "sta");
    return p;
}

void setupWebServer() {
    // 打开网页时自动断开服务器，关闭/离开时恢复
    server.on("/", HTTP_GET, [](AsyncWebServerRequest* req){
        wifi_mode_t wmode = WiFi.getMode();
        bool isAP = config.ap_mode || (wmode == WIFI_AP) || (wmode == WIFI_AP_STA);

        // AP模式（含WiFi连接失败临时AP）→ 极简配网页
        if (isAP) {
            if (SPIFFS.exists("/ap.html"))
                req->send(SPIFFS, "/ap.html", "text/html");
            else
                req->send_P(200, "text/html", AP_PAGE);
            return;
        }

        // ★ 配置模式下直接发完整设置页（此时无TCP、内存充裕）
        if (config_mode) {
            if (SPIFFS.exists("/index.html"))
                req->send(SPIFFS, "/index.html", "text/html");
            else
                req->send(200, "text/html", getSetupPage());
            return;
        }

        // ★ 正常TCP模式下访问"/" → 写Flash标志、发跳转页、重启进配置模式
        // 不加载任何大页面，避免OOM
        preferences.begin("ledmatrix", false);
        preferences.putBool("cfg_mode", true);
        preferences.end();

        req->send(200, "text/html",
            "<html><head><meta charset='UTF-8'>"
            "<meta name='viewport' content='width=device-width,initial-scale=1'>"
            "<style>"
            "body{font-family:sans-serif;background:#667eea;display:flex;"
            "align-items:center;justify-content:center;min-height:100vh;margin:0}"
            ".card{background:#fff;border-radius:16px;padding:32px 24px;max-width:340px;"
            "width:100%;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,.2)}"
            "h2{color:#333;margin-bottom:12px}p{color:#666;font-size:14px;line-height:1.8}"
            ".spinner{width:40px;height:40px;border:4px solid #e0e0e0;"
            "border-top-color:#667eea;border-radius:50%;"
            "animation:spin 1s linear infinite;margin:20px auto}"
            "@keyframes spin{to{transform:rotate(360deg)}}"
            "</style></head><body>"
            "<div class='card'>"
            "<h2>🔄 进入设置模式</h2>"
            "<div class='spinner'></div>"
            "<p>设备正在重启进入配置模式<br>"
            "约3秒后请重新访问此页面</p>"
            "</div></body></html>"
        );

        xTaskCreate([](void*){
            vTaskDelay(pdMS_TO_TICKS(1500));
            ESP.restart();
            vTaskDelete(NULL);
        }, "cfg_reboot", 2048, NULL, 1, NULL);
    });

    // AP模式专用：只保存SSID和密码，重启后自动以STA模式连WiFi
    server.on("/api/wifi", HTTP_POST,
        [](AsyncWebServerRequest* req){},
        NULL,
        [](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total){
            DynamicJsonDocument doc(256), resp(128);
            if (deserializeJson(doc, data)) {
                resp["success"] = false; resp["error"] = "JSON解析失败";
            } else {
                const char* ssid = doc["ssid"] | "";
                const char* pass = doc["pass"] | "";
                if (strlen(ssid) == 0) {
                    resp["success"] = false; resp["error"] = "SSID不能为空";
                } else {
                    strlcpy(config.wifi_ssid,     ssid, sizeof(config.wifi_ssid));
                    strlcpy(config.wifi_password, pass, sizeof(config.wifi_password));
                    // 如果填了服务器IP则一并保存，没填则保留原值
                    const char* srv_ip = doc["server_ip"] | "";
                    if (strlen(srv_ip) > 0)
                        strlcpy(config.server_ip, srv_ip, sizeof(config.server_ip));
                    int srv_port = doc["server_port"] | 0;
                    if (srv_port > 0)
                        config.server_port = srv_port;
                    config.ap_mode = false;
                    saveConfig();
                    // 清除配置模式标志，重启后进正常TCP模式
                    preferences.begin("ledmatrix", false);
                    preferences.putBool("cfg_mode", false);
                    preferences.end();
                    resp["success"] = true;
                    Serial.printf("AP配网：WiFi=%s  服务器=%s:%d，2秒后重启\n",
                                  ssid, config.server_ip, config.server_port);
                    xTaskCreate([](void*){ vTaskDelay(pdMS_TO_TICKS(2000)); ESP.restart(); vTaskDelete(NULL); },
                                "reboot", 2048, NULL, 1, NULL);
                }
            }
            String s; serializeJson(resp, s);
            req->send(200, "application/json", s);
        }
    );

    // 网页主动离开时调用此接口恢复连接（网页beforeunload事件触发）
    server.on("/api/disconnect", HTTP_POST, [](AsyncWebServerRequest* req){
        web_mode_active = false;
        last_reconnect_attempt = 0;  // 立即允许重连
        req->send(200, "application/json", "{\"success\":true}");
        Serial.println("网页关闭，恢复服务器连接");
    });

    server.on("/api/status", HTTP_GET, [](AsyncWebServerRequest* req){
        DynamicJsonDocument doc(384);
        String currentIP;
        if (WiFi.getMode() == WIFI_AP || WiFi.getMode() == WIFI_AP_STA)
            currentIP = WiFi.softAPIP().toString();
        else
            currentIP = WiFi.localIP().toString();
        doc["wifi_connected"]   = wifi_connected;
        doc["ssid"]             = config.wifi_ssid;
        doc["pass"]             = config.wifi_password;
        doc["ip"]               = currentIP;
        doc["server_connected"] = connected_to_server;
        doc["brightness"]       = config.brightness;
        doc["rotation"]         = config.rotation;
        doc["color_map"]        = config.color_map;
        doc["ap_mode"]          = config.ap_mode;
        doc["server_ip"]        = config.server_ip;
        doc["server_port"]      = config.server_port;
        doc["device_id"]        = config.device_id;
        String s; serializeJson(doc, s);
        req->send(200, "application/json", s);
    });

    // 亮度：即时生效，保存到 Flash
    server.on("/api/brightness", HTTP_GET, [](AsyncWebServerRequest* req){
        if (!req->hasParam("value")){ req->send(400,"application/json","{\"error\":\"missing value\"}"); return; }
        int v = constrain(req->getParam("value")->value().toInt(), 0, 255);
        config.brightness = v;
        dma_display->setBrightness8(v);
        preferences.begin("ledmatrix",false); preferences.putUChar("brightness",v); preferences.end();
        DynamicJsonDocument doc(64); doc["success"]=true; doc["brightness"]=v;
        String s; serializeJson(doc,s); req->send(200,"application/json",s);
    });

    // 旋转：即时生效，保存到 Flash
    server.on("/api/rotation", HTTP_GET, [](AsyncWebServerRequest* req){
        if (!req->hasParam("value")){ req->send(400,"application/json","{\"error\":\"missing value\"}"); return; }
        int v = constrain(req->getParam("value")->value().toInt(), 0, 3);
        config.rotation = v;
        preferences.begin("ledmatrix",false); preferences.putUChar("rotation",v); preferences.end();
        DynamicJsonDocument doc(64); doc["success"]=true; doc["rotation"]=v;
        String s; serializeJson(doc,s); req->send(200,"application/json",s);
        Serial.printf("旋转设为 %d°\n", v*90);
    });

    // 颜色映射：即时生效，保存到 Flash
    server.on("/api/colormap", HTTP_GET, [](AsyncWebServerRequest* req){
        if (!req->hasParam("value")){ req->send(400,"application/json","{\"error\":\"missing value\"}"); return; }
        int v = constrain(req->getParam("value")->value().toInt(), 0, 5);
        config.color_map = v;
        preferences.begin("ledmatrix",false); preferences.putUChar("color_map",v); preferences.end();
        const char* names[] = {"RGB","RBG","GRB","GBR","BRG","BGR"};
        Serial.printf("颜色映射设为 %s\n", names[v]);
        DynamicJsonDocument doc(64); doc["success"]=true; doc["color_map"]=v;
        String s; serializeJson(doc,s); req->send(200,"application/json",s);
    });

    // 设备ID：即时生效，保存到 Flash，下次连接服务器时使用新ID
    server.on("/api/deviceid", HTTP_POST,
        [](AsyncWebServerRequest* req){},
        NULL,
        [](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total){
            DynamicJsonDocument doc(128), resp(128);
            if (deserializeJson(doc, data)) {
                resp["success"]=false; resp["error"]="JSON解析失败";
            } else {
                const char* id = doc["device_id"] | "";
                if (strlen(id) == 0) {
                    resp["success"]=false; resp["error"]="ID不能为空";
                } else {
                    strlcpy(config.device_id, id, sizeof(config.device_id));
                    preferences.begin("ledmatrix",false);
                    preferences.putString("device_id", config.device_id);
                    preferences.end();
                    Serial.printf("设备ID更新为: %s\n", config.device_id);
                    resp["success"]=true; resp["device_id"]=config.device_id;
                }
            }
            String s; serializeJson(resp,s); req->send(200,"application/json",s);
        }
    );

    // 保存配置
    server.on("/api/config", HTTP_POST,
        [](AsyncWebServerRequest* req){},
        NULL,
        [](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total){
            DynamicJsonDocument doc(512), resp(128);
            if (deserializeJson(doc, data)) {
                resp["success"]=false; resp["error"]="JSON解析失败";
            } else {
                config.ap_mode = (strcmp(doc["mode"]|"","ap")==0);
                strlcpy(config.wifi_ssid,     doc["ssid"]      |"", sizeof(config.wifi_ssid));
                strlcpy(config.server_ip,     doc["server_ip"] |"", sizeof(config.server_ip));
                config.server_port = doc["server_port"] | 8080;
                config.brightness  = doc["brightness"]  | 128;
                config.rotation    = constrain((int)(doc["rotation"]|0), 0, 3);
                config.color_map   = constrain((int)(doc["color_map"]|0), 0, 5);
                // 密码：只有前端传了 pass 字段才覆盖（空密码保留旧值）
                if (doc.containsKey("pass")) {
                    const char* p = doc["pass"] | "";
                    if (strlen(p) > 0)
                        strlcpy(config.wifi_password, p, sizeof(config.wifi_password));
                }
                // 设备ID：若前端传了则更新
                if (doc.containsKey("device_id")) {
                    const char* id = doc["device_id"] | "";
                    if (strlen(id) > 0)
                        strlcpy(config.device_id, id, sizeof(config.device_id));
                }
                saveConfig();
                // ★ 清除配置模式标志，重启后回到正常TCP模式
                preferences.begin("ledmatrix", false);
                preferences.putBool("cfg_mode", false);
                preferences.end();
                resp["success"]=true; resp["message"]="已保存";
                xTaskCreate([](void*){ vTaskDelay(pdMS_TO_TICKS(2000)); ESP.restart(); vTaskDelete(NULL); },
                            "reboot",2048,NULL,1,NULL);
            }
            String s; serializeJson(resp,s); req->send(200,"application/json",s);
        }
    );

    server.on("/api/test", HTTP_GET, [](AsyncWebServerRequest* req){
        DynamicJsonDocument doc(128);
        if (WiFi.status()!=WL_CONNECTED && !config.ap_mode) {
            doc["success"]=false; doc["message"]="WiFi未连接";
        } else {
            WiFiClient t;
            if (t.connect(config.server_ip, config.server_port)){
                doc["success"]=true; doc["message"]="服务器连接成功"; t.stop();
            } else { doc["success"]=false; doc["message"]="无法连接到服务器"; }
        }
        String s; serializeJson(doc,s); req->send(200,"application/json",s);
    });

    server.on("/api/reset", HTTP_POST, [](AsyncWebServerRequest* req){
        resetConfig();
        // 清除配置模式标志
        preferences.begin("ledmatrix", false);
        preferences.putBool("cfg_mode", false);
        preferences.end();
        req->send(200,"application/json","{\"success\":true}");
        xTaskCreate([](void*){ vTaskDelay(pdMS_TO_TICKS(2000)); ESP.restart(); vTaskDelete(NULL); },
                    "reboot",2048,NULL,1,NULL);
    });

    // ---- OTA 固件更新 ----
    // upload回调只负责接收数据并写入Update
    // 完成回调检查结果并重启
    // 整个过程在AsyncTCP的core 0 task里执行，networkTask在core 1不干扰
    server.on("/api/update", HTTP_POST,
        // ① 请求完成回调
        [](AsyncWebServerRequest* req) {
            bool ok = !Update.hasError();
            if (ok) {
                Serial.println("OTA 成功，2秒后重启");
                req->send(200, "application/json", "{\"success\":true}");
                // 延迟重启，确保响应先发出去
                xTaskCreate([](void*){
                    vTaskDelay(pdMS_TO_TICKS(2000));
                    ESP.restart();
                    vTaskDelete(NULL);
                }, "ota_reboot", 2048, NULL, 5, NULL);
            } else {
                StreamString ss;
                Update.printError(ss);
                String err = ss.c_str();
                err.trim();
                Serial.printf("OTA 失败: %s\n", err.c_str());
                String body = "{\"success\":false,\"error\":\"" + err + "\"}";
                req->send(200, "application/json", body);
            }
            ota_active = false;
        },
        // ② 分块上传回调
        [](AsyncWebServerRequest* req, String filename,
           size_t index, uint8_t* data, size_t len, bool final) {

            if (index == 0) {
                Serial.printf("\n========== OTA 开始 ==========\n");
                Serial.printf("文件: %s\n", filename.c_str());
                ota_written = 0;
                ota_active  = true;

                // 停掉TCP图像流，避免Flash竞争
                if (tcp_client.connected()) {
                    tcp_client.stop();
                    connected_to_server = false;
                }

                // 暂停networkTask，让出core 1给displayTask
                if (network_task_handle) {
                    vTaskSuspend(network_task_handle);
                }

                // LED显示OTA提示
                dma_display->clearScreen();
                dma_display->setTextSize(1);
                dma_display->setTextColor(dma_display->color565(255, 200, 0));
                dma_display->setCursor(4, 18); dma_display->print("OTA Update");
                dma_display->setCursor(4, 30); dma_display->print("Please wait");
                dma_display->flipDMABuffer();

                // 关键：begin必须在所有Flash写操作停止后调用
                // U_FLASH = 写固件分区（不是SPIFFS）
                if (!Update.begin(UPDATE_SIZE_UNKNOWN, U_FLASH)) {
                    StreamString ss; Update.printError(ss);
                    Serial.printf("OTA begin 失败: %s\n", ss.c_str());
                    if (network_task_handle) vTaskResume(network_task_handle);
                    return;
                }
                Serial.println("OTA begin OK");
            }

            if (Update.hasError()) return;  // 已出错，跳过后续数据

            if (len > 0) {
                size_t written = Update.write(data, len);
                ota_written += written;
                if (written != len) {
                    StreamString ss; Update.printError(ss);
                    Serial.printf("OTA write 失败 (wrote %d of %d): %s\n",
                                  (int)written, (int)len, ss.c_str());
                } else {
                    // 每64KB打一次进度
                    if ((ota_written % (64 * 1024)) < len) {
                        Serial.printf("OTA 进度: %d KB\n", (int)(ota_written / 1024));
                    }
                }
            }

            if (final) {
                Serial.printf("OTA 上传完成: 共 %d bytes\n", (int)ota_written);
                if (Update.end(true)) {
                    Serial.println("OTA end OK — 固件已写入，等待重启");
                    SPIFFS.format();
                    Serial.println("SPIFFS格式化完成");
                    // LED显示成功
                    dma_display->clearScreen();
                    dma_display->setTextColor(dma_display->color565(0, 255, 0));
                    dma_display->setCursor(2, 18); dma_display->print("OTA Done!");
                    dma_display->setCursor(2, 30); dma_display->print("Rebooting...");
                    dma_display->setCursor(2, 48); dma_display->print("SPI Formatted!");
                    dma_display->flipDMABuffer();
                } else {
                    StreamString ss; Update.printError(ss);
                    Serial.printf("OTA end 失败: %s\n", ss.c_str());
                    // 恢复networkTask
                    if (network_task_handle) vTaskResume(network_task_handle);
                }
            }
        }
    );

    ws.onEvent([](AsyncWebSocket* s, AsyncWebSocketClient* c, AwsEventType t, void* a, uint8_t* d, size_t l){
        if      (t==WS_EVT_CONNECT)    Serial.printf("WS连接: %u\n", c->id());
        else if (t==WS_EVT_DISCONNECT) Serial.printf("WS断开: %u\n", c->id());
    });
    server.addHandler(&ws);
    server.begin();
    Serial.println("WebServer started on core 0 (via AsyncTCP task)");
}

// ============ WiFi ============

bool connectWiFi() {


    if (config.ap_mode) {
        WiFi.mode(WIFI_AP);
        WiFi.softAP(AP_SSID, AP_PASSWORD);
        delay(500);
        wifi_connected = true;
        // AP模式也启动滚动显示（显示AP IP）
        xTaskCreatePinnedToCore(scrollIPTask, "ScrollIP", 3072, NULL, 1, &scroll_ip_task_handle, 1);
        return true;
    }

    WiFi.mode(WIFI_STA);
    WiFi.begin(config.wifi_ssid, config.wifi_password);
    int att = 0;
    while (WiFi.status() != WL_CONNECTED && att < 30) {
        showWiFiConnectingScreen(att++);
        delay(500);
    }
    if (WiFi.status() == WL_CONNECTED) {
        Serial.printf("WiFi OK: %s\n", WiFi.localIP().toString().c_str());
        wifi_connected = true;
        xTaskCreatePinnedToCore(scrollIPTask, "ScrollIP", 3072, NULL, 1, &scroll_ip_task_handle, 1);
        return true;
    }

    // WiFi连接失败：写入持久AP模式，与用户手动设置的AP模式完全一致
    Serial.println("WiFi连接失败，切换为持久AP模式");
    config.ap_mode = true;
    saveConfig();

    WiFi.mode(WIFI_AP);
    WiFi.softAP(AP_SSID, AP_PASSWORD);
    delay(500);
    wifi_connected = true;

    xTaskCreatePinnedToCore(scrollIPTask, "ScrollIP", 3072, NULL, 1, &scroll_ip_task_handle, 1);
    return true;
}

// ============ 非阻塞 TCP ============

void connectToServer() {
    if (!tcp_client.connect(config.server_ip, config.server_port)){
        connected_to_server = false; return;
    }
    connected_to_server = true;
    rx_state = RX_IDLE; rx_line_buf = "";
    Serial.println("服务器连接成功");
    DynamicJsonDocument doc(256);
    doc["type"]="client_info"; doc["width"]=TOTAL_WIDTH; doc["height"]=TOTAL_HEIGHT;
    doc["format"]="RGB565"; doc["panels"]=PANEL_CHAIN;
    doc["panel_width"]=PANEL_RES_X; doc["panel_height"]=PANEL_RES_Y;
    doc["device_id"]=config.device_id;
    doc["audio_format"]="PCM16"; doc["audio_rate"]=I2S_SAMPLE_RATE; doc["audio_ch"]=I2S_CHANNELS;
    String msg; serializeJson(doc,msg); tcp_client.println(msg);
}

void processIncomingData() {
    if (!tcp_client.connected()){ connected_to_server=false; rx_state=RX_IDLE; audio_rx_state=AUDIO_IDLE; rx_line_buf=""; return; }
    int avail = tcp_client.available();
    if (avail == 0) return;

    if (rx_state == RX_IDLE && audio_rx_state == AUDIO_IDLE) {
        // 两个流都空闲：读取头部行
        int to_read = min(avail, 128);
        for (int i=0; i<to_read; i++) {
            char c = tcp_client.read();
            if (c == '\n') {
                rx_line_buf.trim();
                if (rx_line_buf.startsWith("{")) {
                    DynamicJsonDocument doc(512);
                    if (!deserializeJson(doc, rx_line_buf)) {
                        const char* type = doc["type"];
                        if (type && strcmp(type,"frame_data")==0) {
                            int w=doc["width"]|0, h=doc["height"]|0, sz=doc["data_size"]|0;
                            if (w==TOTAL_WIDTH && h==TOTAL_HEIGHT && sz>0 && strcmp(doc["format"]|"","RGB565")==0){
                                rx_buf_idx=write_buffer; rx_expected=sz; rx_received=0;
                                rx_frame_start=millis(); rx_state=RX_RECEIVING_IMG;
                            }
                        } else if (type && strcmp(type,"audio_data")==0) {
                            int sz = doc["data_size"]|0;
                            if (sz > 0 && sz <= 8192 && audio_rx_buf) {
                                audio_rx_expected = sz;
                                audio_rx_received = 0;
                                audio_frame_start = millis();
                                audio_rx_state = AUDIO_RECEIVING;
                            }
                        } else if (type && strcmp(type,"brightness")==0) {
                            config.brightness = doc["value"]|config.brightness;
                            dma_display->setBrightness8(config.brightness); saveConfig();
                        } else if (type && strcmp(type,"clear")==0) {
                            for(int b=0;b<NUM_BUFFERS;b++) memset(buffers[b],0,TOTAL_WIDTH*TOTAL_HEIGHT*2);
                            xQueueReset(frame_queue);
                        }
                    }
                }
                rx_line_buf = ""; break;
            } else {
                rx_line_buf += c;
                if (rx_line_buf.length() > 1024) rx_line_buf = "";
            }
        }
    } else if (rx_state == RX_RECEIVING_IMG) {
        if (millis()-rx_frame_start > RX_TIMEOUT_MS){ rx_state=RX_IDLE; rx_line_buf=""; return; }
        int chunk = min(avail, rx_expected-rx_received);
        if (chunk > 0) {
            uint8_t* ptr = (uint8_t*)buffers[rx_buf_idx] + rx_received;
            rx_received += tcp_client.readBytes(ptr, chunk);
        }
        if (rx_received >= rx_expected) {
            xQueueSend(frame_queue, &rx_buf_idx, 0);
            write_buffer = (write_buffer+1) % NUM_BUFFERS;
            rx_state = RX_IDLE; rx_line_buf = "";
        }
    } else if (audio_rx_state == AUDIO_RECEIVING) {
        if (millis()-audio_frame_start > AUDIO_RX_TIMEOUT_MS){ audio_rx_state=AUDIO_IDLE; return; }
        int chunk = min(avail, audio_rx_expected - audio_rx_received);
        if (chunk > 0) {
            audio_rx_received += tcp_client.readBytes(audio_rx_buf + audio_rx_received, chunk);
        }
        if (audio_rx_received >= audio_rx_expected) {
            playAudioChunk(audio_rx_buf, audio_rx_received);
            audio_rx_state = AUDIO_IDLE;
        }
    }
}

// ============ FreeRTOS 任务 ============

void infoDisplayTask(void* p) {
    while (true) {
        if (!server_mode_active && show_info_screen) showInfoScreen();
        vTaskDelay(pdMS_TO_TICKS(5000));
    }
}

// 旋转缓冲区（只在需要旋转时分配）
static uint16_t* rot_buf = nullptr;

void displayTask(void* p) {
    TickType_t xLast = xTaskGetTickCount();
    int cur_buf = 1;

    // 如果需要旋转，分配一个临时缓冲区
    if (config.rotation != 0) {
        rot_buf = (uint16_t*)malloc(TOTAL_WIDTH * TOTAL_HEIGHT * sizeof(uint16_t));
        if (!rot_buf) Serial.println("旋转缓冲分配失败，将不旋转");
    }

    while (true) {
        int idx;
        if (xQueueReceive(frame_queue, &idx, 0) == pdTRUE) {
            server_mode_active = true;
            show_info_screen   = false;
            cur_buf            = idx;
        }
        if (server_mode_active) {
            uint16_t* src = buffers[cur_buf];
            uint16_t* draw_buf = src;

            // 应用旋转
            if (config.rotation != 0 && rot_buf != nullptr) {
                rotateAndCopy(src, rot_buf, config.rotation);
                draw_buf = rot_buf;
            }

            for (int y=0; y<TOTAL_HEIGHT; y++)
                for (int x=0; x<TOTAL_WIDTH; x++)
                    dma_display->drawPixel(x, y,
                        remapColor(draw_buf[y*TOTAL_WIDTH+x], config.color_map));
            dma_display->flipDMABuffer();
        }

        // FPS 统计
        frame_count++;
        unsigned long now = millis();
        if (now - last_fps_time >= 1000) {
            current_fps = frame_count*1000.0f/(now-last_fps_time);
            frame_count = 0; last_fps_time = now;
            Serial.printf("FPS:%.1f heap:%d rot:%d°\n", current_fps, ESP.getFreeHeap(), config.rotation*90);
        }

        vTaskDelayUntil(&xLast, pdMS_TO_TICKS(40));
    }
}

// ★ networkTask 固定在 core 1，与 displayTask 同核心
// ★ AsyncWebServer/AsyncTCP 有自己的 task，运行在 core 0
// 两者完全不竞争，Web 请求永远不被 TCP 接收阻塞
void networkTask(void* p) {
    if (config.ap_mode) {
        while (true) { vTaskDelay(pdMS_TO_TICKS(1000)); }
        return;
    }
    while (true) {
        // 网页打开时主动断开服务器连接，释放内存供页面渲染
        if (web_mode_active) {
            if (connected_to_server) {
                tcp_client.stop();
                connected_to_server = false;
                server_mode_active  = false;
                show_info_screen    = true;
                rx_state = RX_IDLE; rx_line_buf = "";
                Serial.println("网页模式：已断开服务器连接");
            }
            vTaskDelay(pdMS_TO_TICKS(500));
            continue;
        }

        if (!connected_to_server) {
            if (millis()-last_reconnect_attempt > 5000) {
                connectToServer();
                last_reconnect_attempt = millis();
            }
        } else {
            processIncomingData();
        }

        if (!connected_to_server && server_mode_active) {
            server_mode_active = false;
            show_info_screen   = true;
            showInfoScreen();
        }
        vTaskDelay(pdMS_TO_TICKS(5));
    }
}

// ============ 初始化 ============

// ============ I2S 音频初始化 ============

void initI2SAudio() {
    i2s_config_t i2s_cfg = {
        .mode                 = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX),
        .sample_rate          = I2S_SAMPLE_RATE,
        .bits_per_sample      = I2S_BITS_PER_SAMPLE_16BIT,
        .channel_format       = I2S_CHANNEL_FMT_ONLY_LEFT, // Mono → 左声道
        .communication_format = I2S_COMM_FORMAT_STAND_I2S,
        .intr_alloc_flags     = ESP_INTR_FLAG_LEVEL1,
        .dma_buf_count        = 8,
        .dma_buf_len          = 256,
        .use_apll             = false,
        .tx_desc_auto_clear   = true,
    };
    i2s_pin_config_t pin_cfg = {
        .bck_io_num   = I2S_BCLK_PIN,
        .ws_io_num    = I2S_LRC_PIN,
        .data_out_num = I2S_DOUT_PIN,
        .data_in_num  = I2S_PIN_NO_CHANGE,
    };
    i2s_driver_install(I2S_PORT, &i2s_cfg, 0, NULL);
    i2s_set_pin(I2S_PORT, &pin_cfg);
    i2s_zero_dma_buffer(I2S_PORT);

    // 分配音频接收缓冲区（最大一帧音频，16kHz×100ms×2bytes=3200）
    audio_rx_buf = (uint8_t*)malloc(8192);
    if (!audio_rx_buf) Serial.println("音频缓冲分配失败！");
    else Serial.printf("I2S音频初始化完成: LRC=%d BCLK=%d DIN=%d %dHz Mono\n",
                        I2S_LRC_PIN, I2S_BCLK_PIN, I2S_DOUT_PIN, I2S_SAMPLE_RATE);
}

// 将 PCM16 音频数据送入 I2S DMA（非阻塞：DMA满时丢弃）
void playAudioChunk(const uint8_t* data, size_t len) {
    if (!audio_rx_buf) return;
    size_t written = 0;
    // 使用0超时：DMA缓冲满时立即返回，不阻塞networkTask
    i2s_write(I2S_PORT, data, len, &written, 0);
}

void initLEDPanel() {
    HUB75_I2S_CFG::i2s_pins _pins = {
        R1_PIN,G1_PIN,B1_PIN, R2_PIN,G2_PIN,B2_PIN,
        A_PIN,B_PIN,C_PIN,D_PIN,E_PIN, LAT_PIN,OE_PIN,CLK_PIN
    };
    HUB75_I2S_CFG mxconfig(PANEL_RES_X, PANEL_RES_Y, PANEL_CHAIN, _pins);
    mxconfig.gpio.e         = E_PIN;
    mxconfig.clkphase       = true;
    //mxconfig.driver         = HUB75_I2S_CFG::ICN2038S;
    // mxconfig.i2sspeed    = HUB75_I2S_CFG::HZ_20M;  // ESP32不支持，使用默认
    mxconfig.latch_blanking = 1;
    mxconfig.double_buff    = true;
    dma_display = new MatrixPanel_I2S_DMA(mxconfig);
    dma_display->begin();
    dma_display->setBrightness8(config.brightness);
    dma_display->clearScreen();
    Serial.printf("LED panel: %dx%d 亮度:%d 旋转:%d°\n", TOTAL_WIDTH, TOTAL_HEIGHT, config.brightness, config.rotation*90);
}

void setup() {
    Serial.begin(115200);
    Serial.println("\nESP32 HUB75 LED Client Starting...");

    loadConfig();
    Serial.printf("Free heap: %d  cfg_mode=%d\n", ESP.getFreeHeap(), config_mode);

    initLEDPanel();
    initI2SAudio();
    showBootScreen();   // Hello! + ClawMatrix，停留1秒
    if (config_mode) {
        // ========== 纯Web配置模式 ==========
        // 不分配帧缓冲、不启动TCP任务、内存充裕
        Serial.println("*** 配置模式启动 ***");

        // LED显示提示
        clearDisplay();
        dma_display->setTextSize(1);
        dma_display->setTextColor(dma_display->color565(255, 200, 0));
        dma_display->setCursor(4, 8);  dma_display->print("Config Mode");
        dma_display->setTextColor(dma_display->color565(0, 255, 255));
        dma_display->setCursor(4, 22); dma_display->print("Connect WiFi:");
        dma_display->setCursor(4, 34); dma_display->print(AP_SSID);
        dma_display->setTextColor(dma_display->color565(255, 255, 255));
        dma_display->setCursor(4, 48); dma_display->print("192.168.4.1");
        dma_display->flipDMABuffer();

        // 先尝试STA连接（方便在已知WiFi环境下访问设置页）
        // 失败则回落AP模式
        bool sta_ok = false;
        if (strlen(config.wifi_ssid) > 0) {
            WiFi.mode(WIFI_STA);
            WiFi.begin(config.wifi_ssid, config.wifi_password);
            Serial.printf("配置模式尝试连接WiFi: %s\n", config.wifi_ssid);
            for (int i = 0; i < 20; i++) {  // 最多等10秒
                if (WiFi.status() == WL_CONNECTED) { sta_ok = true; break; }
                delay(500);
            }
        }
        if (sta_ok) {
            Serial.printf("配置模式STA连接成功: %s\n", WiFi.localIP().toString().c_str());
            // 更新LED显示IP
            clearDisplay();
            dma_display->setTextSize(1);
            dma_display->setTextColor(dma_display->color565(0, 255, 0));
            dma_display->setCursor(4, 4);  dma_display->print("Conf Mode");
            dma_display->setTextColor(dma_display->color565(255, 255, 0));
            dma_display->setCursor(4, 16); dma_display->print("Browser:");
            dma_display->setTextColor(dma_display->color565(0, 255, 255));
            String ip = WiFi.localIP().toString();
            dma_display->setCursor(4, 28); dma_display->print(ip.substring(0, min((int)ip.length(), 16)));
            dma_display->flipDMABuffer();
        } else {
            // STA失败，启AP
            WiFi.mode(WIFI_AP);
            WiFi.softAP(AP_SSID, AP_PASSWORD);
            Serial.printf("配置模式AP启动: %s  IP: %s\n", AP_SSID, WiFi.softAPIP().toString().c_str());
        }

        initSPIFFS();
        setupWebServer();
        Serial.println("配置模式WebServer已启动，等待浏览器连接...");
        return;  // 不创建任何TCP/display任务
    }

    // ========== 正常TCP显示模式（WiFi连接失败后可能切换为AP） ==========
    size_t buf_size = TOTAL_WIDTH * TOTAL_HEIGHT * sizeof(uint16_t);
    for (int i=0; i<NUM_BUFFERS; i++) {
        buffers[i] = (uint16_t*)malloc(buf_size);
        if (!buffers[i]){ Serial.printf("Buffer %d alloc failed!\n",i); while(1); }
        memset(buffers[i], 0, buf_size);
    }
    Serial.printf("Buffers OK, heap: %d\n", ESP.getFreeHeap());

    frame_queue = xQueueCreate(2, sizeof(int));

    connectWiFi();
    initSPIFFS();
    setupWebServer();

    // WiFi连接失败时 connectWiFi() 会将 config.ap_mode 置为 true
    // 此时只需要 WebServer，释放帧缓冲，不启动TCP/Display任务
    if (config.ap_mode) {
        for (int i=0; i<NUM_BUFFERS; i++) { free(buffers[i]); buffers[i]=nullptr; }
        Serial.println("AP模式：WebServer已启动，TCP/Display任务跳过");
        return;
    }

    xTaskCreatePinnedToCore(displayTask,     "Display", 6144, NULL, 3, &display_task_handle,      1);
    xTaskCreatePinnedToCore(networkTask,     "Network", 8192, NULL, 2, &network_task_handle,      1);
    xTaskCreatePinnedToCore(infoDisplayTask, "Info",    2048, NULL, 1, &info_display_task_handle, 1);

    Serial.println("Init complete. WebServer=core0  Network+Display=core1");
}

void updateFPS() {} // 已移入 displayTask

void sendStatusUpdate() {
    if (ws.count() > 0) {
        DynamicJsonDocument doc(256);
        doc["free_heap"]       = ESP.getFreeHeap();
        doc["fps"]             = current_fps;
        doc["server_connected"]= connected_to_server;
        doc["wifi_connected"]  = wifi_connected;
        doc["mode"]            = config.ap_mode ? "ap" : "sta";
        doc["brightness"]      = config.brightness;
        doc["rotation"]        = config.rotation;
        String msg; serializeJson(doc, msg);
        ws.textAll(msg);
    }
}

void loop() {
    ws.cleanupClients();
    wifi_connected = (config.ap_mode || WiFi.status()==WL_CONNECTED);
    if (!config.ap_mode && !wifi_connected && millis()-last_reconnect_attempt>10000) {
        WiFi.reconnect(); last_reconnect_attempt=millis();
    }
    sendStatusUpdate();
    vTaskDelay(pdMS_TO_TICKS(200));
}
